Automate release on pyproject version bump#160
Conversation
Switch the release model from "push a tag" to "bump the version in pyproject.toml on main." This matches the pattern used in our other modern repos (e.g. equipment-status-board). release.yml now triggers on push to main, reads the version from pyproject.toml, and compares it to the latest GitHub release. When the version is higher it auto-tags the commit (bare version, no `v` prefix, matching existing tags), builds and pushes the Docker image to GHCR, publishes to PyPI, and creates a GitHub release with generated notes plus docker-pull / PyPI pointers. When the version is unchanged the workflow no-ops. Carries forward the lowercase-GHCR-repo fix and the SBOM/OCI labels; the image version label now comes from the detected version rather than github.ref_name. Docs updated accordingly: contributing.rst Release Process and a new Releasing section in CLAUDE.md (README has no release content). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SHOULD=$(python3 -c " | ||
| current = tuple(int(x) for x in '$CURRENT_VERSION'.split('.')) | ||
| latest = tuple(int(x) for x in '$LATEST_VERSION'.split('.')) | ||
| print('true' if current > latest else 'false') | ||
| ") |
There was a problem hiding this comment.
Two related issues in this version comparison block:
1. Shell injection via variable interpolation
$CURRENT_VERSION and $LATEST_VERSION are interpolated directly into a python3 -c source string, landing inside single-quoted Python string literals. The version regex [^"]+ on line 33 permits any character except " — including a single quote, which is sufficient to break out of the Python literal. In a runner holding contents: write, packages: write, and PYPI_TOKEN this is a code-execution sink. (You already avoided this exact pattern later with the comment # Build release body in a file to avoid shell escaping issues — the same concern applies here.)
Context: release.yml L43–49
2. int() crash on non-numeric version components
tuple(int(x) for x in ...) has no error handling. A pre-release version like 0.15.0b1 or 0.15.0.dev0 (produced by poetry version prerelease) splits to ['0', '15', '0b1'] — int('0b1') raises ValueError and the workflow fails.
Context: release.yml L44–48
Suggested fix: pass versions through the environment (data, not source code) and add error handling:
# Replace lines 44–48 with:
SHOULD=$(CURRENT_VER="$CURRENT_VERSION" LATEST_VER="$LATEST_VERSION" python3 -c "
import os, sys
def to_tuple(v):
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
print(f'non-numeric version: {v}', file=sys.stderr); sys.exit(1)
print('true' if to_tuple(os.environ['CURRENT_VER']) > to_tuple(os.environ['LATEST_VER']) else 'false')
")| - name: Build and publish to pypi | ||
| if: steps.version_check.outputs.should_release == 'true' | ||
| uses: JRubics/poetry-publish@v2.1 | ||
| with: | ||
| pypi_token: ${{ secrets.PYPI_TOKEN }} |
There was a problem hiding this comment.
Non-atomic release: PyPI publishes before the GitHub release (the idempotency gate)
should_release is computed by checking whether a GitHub release exists for the current version (line 37). This PyPI publish step (L76–80) runs four steps before "Create GitHub Release" (L87–115). If the job fails or is cancelled after PyPI publishes but before the GitHub release step completes, the next rerun computes should_release=true again (no GitHub release found) and then fails permanently at this step — PyPI rejects re-uploading an already-published version with HTTP 400 ("File already exists"). The release steps are non-atomic and the idempotency gate is written last.
Context: release.yml L75–115
Suggested fixes (pick one):
- Add
skip_existing: truetoJRubics/poetry-publishso reruns skip an already-published version rather than erroring, making this step idempotent. - Reorder steps so the GitHub release is created before the PyPI publish, so the gating condition becomes true before the irreversible action.
Switches the release model from "push a git tag" to "bump the version in
pyproject.tomlonmain", matching the pattern used in our other modern repos (e.g.equipment-status-board).How it works now
release.ymltriggers on push tomainand:pyproject.toml.0.14.0— novprefix, matching existing tags), builds & pushes the Docker image to GHCR, publishes to PyPI, and creates a GitHub release with auto-generated notes plus docker-pull / PyPI pointers.No more manual tagging.
Notes
versionlabel now comes from the detected version instead ofgithub.ref_name(which would bemainon a branch push).JRubics/poetry-publish; theCLAUDE_CODE_OAUTH_TOKENandPYPI_TOKENsecrets are already present on the repo.Docs
docs/source/contributing.rst— rewrote the Release Process section.CLAUDE.md— added a Releasing section documenting the auto-release-on-version-bump flow and "do not tag manually."README.rst— no change (it has no release/tag content).Effect on the pending 0.14.0 release
pyproject.tomlis already at0.14.0and the latest release is0.13.0. Merging this PR will itself trigger the new workflow and auto-release0.14.0— so this replaces the manual tag-and-release step we paused earlier.🤖 Generated with Claude Code